Amazon Connect + Whisper + GPT-4 Turboで、発話から個人情報(名前、住所、生年月日)を正しく認識できるか試してみた

Amazon Connect + Whisper + GPT-4 Turboで、発話から個人情報(名前、住所、生年月日)を正しく認識できるか試してみた

Clock Icon2023.11.14

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

Amazon Connect + Whisper API + GPT-4 Turboで、電話での発話から個人情報を正しく認識できるか試してみました。

コールセンターでは、有人対応から無人対応に変更したいニーズが増えているように思います。

お客さんから個人情報の聞き取りを無人対応する場合、生年月日や名前、住所などを発話どおりに認識してくれるか検証しました。

今回の記事では、以下の5つの項目を発話し、正しく認識できるか確認します。

  • 住所
  • 名前
  • 英字
  • 数字
  • 生年月日

数字に関しては、電話番号や会員番号、口座番号やクレジットカード番号を聞きとりたいニーズはあると思いますので、認識できるか確認します。

英字に関しては、英数字を絡めたパスワードや会員ID番号をヒアリングするケースがあると思いますので、まずは英字のみを認識できるか確認しました。

ちなみに、Amazon Connect + Lex + Bedrockという組み合わせでのAIチャットボットでの検証は、下記の記事で行いましたので、ご参考ください。(下記記事での文字起こしは、ストリーミング処理ですので、今回のバッチ処理とは条件が異なります)

先に結果

今回の検証の評価は、以下の結果となりました。

項目 認識 備考
住所 建物名や地名(固有名詞)が難しい
名前 一部誤認識あり
英字 問題なく認識した
数字 問題なく認識した
生年月日 問題なく認識した

構成

構成としては、下記の通りです。

Connectのフローの詳細は下記の通りです。

例として、発話で住所を認識させる処理の流れは以下のとおりです。

  1. コンタクトフロー内で「メディアストリーミングの開始」ブロックを使って、Kinesis Video Stream(KVS)への音声のストリーミングを開始します。
  2. 顧客は、住所を含めた発話をします。
  3. 「顧客の入力を保存する」ブロックで、顧客が特定の番号を押すと、ストリーミングを終了します。
  4. 「AWS Lambda関数を呼び出す」ブロックを使い、LambdaでKVSからデータを取得します。取得したデータをWAV形式に変換し、Whisper APIで文字起こしします。文字起こし内容から、GPT-4 Turboで住所のみを抽出します。
  5. プロンプト再生で、住所のみを音声出力します。

以下の図は、電話での対話の流れを示しています。

前提

  • 2023年11月時点での検証内容です。今後のアップデートにより改善される可能性があります。恒久的な結果ではありません。
  • 今回は、いくつかのサンプルで検証を行っただけであり、他のサンプルでも同様の結果となるとは限りません。これらの結果は一例として参照ください。

構築

Lambda

LambdaでWhisper APIやGPT-4 Turboを利用するあたり、OpenAIアカウントAPIキーの発行やOpenAIのPython向けのライブラリをLambdaにアップロードする方法などは、以下の記事を参照ください。

以下の設定を行います

  • 環境変数では、OpenAIのキーを設定
  • タイムアウトは、3秒から30秒に変更
  • OpenAIのPython向けのライブラリをLambdaレイヤーに追加
  • IAMの管理ポリシーAmazonKinesisVideoStreamsReadOnlyAccessを適用

コードは下記の通りです。住所を抽出するためのプロンプトにしています。

import boto3, os, struct, json, openai
from ebmlite import loadSchema
from enum import Enum
from datetime import datetime
from botocore.config import Config
from decimal import Decimal

openai.api_key = os.environ["API_Key"]

def decimal_to_int(obj):
    if isinstance(obj, Decimal):
        return int(obj)

class Mkv(Enum):
    SEGMENT = 0x18538067
    CLUSTER = 0x1F43B675
    SIMPLEBLOCK = 0xA3

class Ebml(Enum):
    EBML = 0x1A45DFA3

class KVSParser:
    def __init__(self, media_content):
        self.__stream = media_content["Payload"]
        self.__schema = loadSchema("matroska.xml")
        self.__buffer = bytearray()

    @property
    def fragments(self):
        return [fragment for chunk in self.__stream if (fragment := self.__parse(chunk))]

    def __parse(self, chunk):
        self.__buffer.extend(chunk)
        header_elements = [e for e in self.__schema.loads(self.__buffer) if e.id == Ebml.EBML.value]
        if header_elements:
            fragment_dom = self.__schema.loads(self.__buffer[:header_elements[0].offset])
            self.__buffer = self.__buffer[header_elements[0].offset:]
            return fragment_dom

def get_simple_blocks(media_content):
    parser = KVSParser(media_content)
    return [b.value for document in parser.fragments for b in 
            next(filter(lambda c: c.id == Mkv.CLUSTER.value, 
                        next(filter(lambda s: s.id == Mkv.SEGMENT.value, document)))) 
            if b.id == Mkv.SIMPLEBLOCK.value]

def create_audio_sample(simple_blocks, margin=4):
    position = 0
    total_length = sum(len(block) - margin for block in simple_blocks)
    combined_samples = bytearray(total_length)
    for block in simple_blocks:
        temp = block[margin:]
        combined_samples[position:position+len(temp)] = temp
        position += len(temp)
    return combined_samples

def convert_bytearray_to_wav(samples):
    length = len(samples)
    channel = 1
    bit_par_sample = 16
    format_code = 1
    sample_rate = 8000
    header_size = 44
    wav = bytearray(header_size + length)
    
    wav[0:4] = b"RIFF"
    wav[4:8] = struct.pack("<I", 36 + length)
    wav[8:12] = b"WAVE"
    wav[12:16] = b"fmt "
    wav[16:20] = struct.pack("<I", 16)
    wav[20:22] = struct.pack("<H", format_code)
    wav[22:24] = struct.pack("<H", channel)
    wav[24:28] = struct.pack("<I", sample_rate)
    wav[28:32] = struct.pack("<I", sample_rate * channel * bit_par_sample // 8)
    wav[32:34] = struct.pack("<H", channel * bit_par_sample // 8)
    wav[34:36] = struct.pack("<H", bit_par_sample)
    wav[36:40] = b"data"
    wav[40:44] = struct.pack("<I", length)
    
    wav[44:] = samples
    return wav

def create_kvs_client():
    region_name = "ap-northeast-1"
    return boto3.client("kinesisvideo", region_name=region_name)

def create_archive_media_client(ep):
    region_name = "ap-northeast-1"
    return boto3.client("kinesis-video-archived-media", endpoint_url=ep, config=Config(region_name=region_name))

def get_media_data(arn, start_timestamp, end_timestamp):
    kvs_client = create_kvs_client()

    list_frags_ep = kvs_client.get_data_endpoint(StreamARN=arn, APIName="LIST_FRAGMENTS")["DataEndpoint"]
    list_frags_client = create_archive_media_client(list_frags_ep)

    fragment_list = list_frags_client.list_fragments(
        StreamARN = arn, 
        FragmentSelector = {
            "FragmentSelectorType": "PRODUCER_TIMESTAMP",
            "TimestampRange": {"StartTimestamp": datetime.fromtimestamp(start_timestamp), "EndTimestamp": datetime.fromtimestamp(end_timestamp)}
        }
    )

    sorted_fragments = sorted(fragment_list["Fragments"], key = lambda fragment: fragment["ProducerTimestamp"])
    fragment_number_array = [fragment["FragmentNumber"] for fragment in sorted_fragments]
    print("Received fragment_number_array:" + json.dumps(fragment_number_array,default=decimal_to_int, ensure_ascii=False))


    get_media_ep = kvs_client.get_data_endpoint(StreamARN=arn, APIName="GET_MEDIA_FOR_FRAGMENT_LIST")["DataEndpoint"]
    get_media_client = create_archive_media_client(get_media_ep)

    media = get_media_client.get_media_for_fragment_list(StreamARN = arn, Fragments = fragment_number_array)
    return media

def save_audio_to_tmp(wav_audio, filename="output.wav"):
    with open(f"/tmp/{filename}", "wb") as out_file:
        out_file.write(wav_audio)

def get_extract_utterances(utterances_content):
    input_text = """
    ## 役割
    あなたはコールセンターの担当者です。ルールに遵守し、顧客の発話から住所のみを出力してください。
    ## ルール
     1. 顧客から住所を含めたお問い合わせが文字起こしされます。
     2. 推測される住所の部分のみを返してください
     3. 住所は、都道府県名、市区町村、丁目、番地、号、建物名、階数を含めてください。
     4. 住所に使用する数字は、全てアラビア数字(1, 2, 3, ...)を使用してください。
    """
    
    inquiry_text = utterances_content

    response = openai.ChatCompletion.create(
        model="gpt-4-1106-preview",
        messages=[
            {"role": "system", "content": input_text},
            {"role": "user", "content": inquiry_text}
        ],
        temperature=0,
    )
    return response["choices"][0]["message"]["content"]

def transcribe_audio_file(file_path):
    with open(file_path, "rb") as audio_file:
        return openai.Audio.transcribe("whisper-1", audio_file)

def lambda_handler(event, context):
    print("Received event:" + json.dumps(event,default=decimal_to_int, ensure_ascii=False))
    media_streams = event["Details"]["ContactData"]["MediaStreams"]["Customer"]["Audio"]
    stream_arn = media_streams["StreamARN"]
    start_time = float(media_streams["StartTimestamp"]) / 1000
    end_time = float(media_streams["StopTimestamp"]) / 1000
    combined_samples = create_audio_sample(
        get_simple_blocks(get_media_data(stream_arn, start_time, end_time)))

    wav_audio = convert_bytearray_to_wav(combined_samples)
    save_audio_to_tmp(wav_audio)

    transcript = transcribe_audio_file("/tmp/output.wav")
    transcript = str(transcript["text"])
    print("Transcript result:" + transcript)
    
    utterance_extraction = get_extract_utterances(transcript)
    print("Utterance_extraction result:" + utterance_extraction)

    return {
        "transcript": utterance_extraction
    }
  • 関数名transcribe_audio_fileでは、Whisper APIで文字起こしを行っています。
  • 関数名get_extract_utterancesでは、GPT-4 Turboで文字起こし内容から住所のみを抽出しています。
    • 関数名get_extract_utterances内のプロンプトは、住所以外の生年月日や名前などの個人情報によって各プロンプトを変えます。
  • 最後に、文字起こしの個人情報の値として、キーをtranscriptとしたJSON形式でreturnしています。
    • 後述しますが、この値は、コンタクフローにおいて、Lambdaを呼び出したブロックの次のブロックで音声出力する際に利用します。

KVSから、メディアデータから録音データを抽出しWAV変換するコード解説は下記をご参考ください。

Connect フロー

Connectフローの全体は、下記の通りです。

「顧客の入力を保存する」ブロックでは、テキストの読み上げは不要なのですが、設定する必要があるため、.としています。

「AWS Lambda 関数を呼び出す」ブロックでは、作成したLambdaを設定します。タイムアウトは、最大の8秒にします。

Lambdaを呼び出したブロックの後に、「プロンプトの再生」で下記の通りにテキストを入力しています。

transcriptは先程、Lambdaのレスポンスで設定したキーですので、$.External.transcriptによって、住所が音声出力されます。

住所は、$.External.transcript 、ですね。

フローは以上です。

試してみる

5つの項目に対して認識するか確認します。

  • 住所
  • 名前
  • 英字
  • 数字
  • 生年月日

前提として、サンプル数は少ないので、他のサンプルでは同じ結果とは限らない可能性が十分にあります。これらの結果は一例として参考にしてください。

また、ゆっくりと明瞭に発話していますので、早口であったり不明瞭な発話の場合、文字起こし精度に影響が出る可能性があります。

住所

住所は、クラスメソッド各拠点の住所で確認しました。

利用したプロンプトは以下の通りです。

    input_text = """
    ## 役割
    あなたはコールセンターの担当者です。ルールに遵守し、顧客の発話から住所のみを出力してください。
    ## ルール
     1. 顧客から住所を含めたお問い合わせが文字起こしされます。
     2. 推測される住所の部分のみを返してください
     3. 住所は、都道府県名、市区町村、丁目、番地、号、建物名、階数を含めてください。
     4. 住所に使用する数字は、全てアラビア数字(1, 2, 3, ...)を使用してください。
    """

1つ目

最初に試した拠点は以下の通りです

  • 発話内容:えーっと住所は、東京都港区西新橋1-1-1 日比谷フォートタワー26階です
  • Whisperでの文字起こし:住所は東京都港区西新橋1-1-1日比谷フォートタワー26階です
  • GPT-4 Turboでの抽出:東京都港区西新橋1-1-1日比谷フォートタワー26階
  • 実行時間:6秒

Whisperでの文字起こしでは、「えーっと」などの意味のない言葉は省略されます。

抽出内容は全く問題ないですね。実行時間が6秒と長いので、録音時間が増えると、実行時間が8秒になりタイムアウトする可能性はありますね。

また、GPT-4 Turboは、2023年4月まで学習されているため、それ以前に建てられた建物であれば認識してくれる可能性が高いと推測します。

2つ目

次の拠点は以下の通りです

  • 発話内容:えーっと住所は、北海道札幌市中央区北3条西1-1-1 札幌ブリックキューブ10階です
  • Whisperでの文字起こし:北海道札幌市中央区北3畳西1-1-1 札幌ブリックキューブ10階です
  • GPT-4 Turboでの抽出:北海道札幌市中央区北3条西1-1-1 札幌ブリックキューブ10階

文字起こしでは、北3畳西となっておりましたが、GPT-4 Turboでの抽出時に北3条西に変換してくれていました。

3つ目

次の拠点は以下の通りです

  • 発話内容:新潟県上越市大和5-1-5 フルサット内です
  • Whisperでの文字起こし:新潟県上越市大和、5-1-5、ふるさと内です。
  • GPT-4 Turboでの抽出:新潟県上越市大和5-1-5ふるさと内

拠点の「フルサット内」は、「ふるさと内」と誤認識されました。建物ですが階数がないので、難しいかったですかね。

4つ目

  • 発話内容:愛知県名古屋市中村区名駅4-24-16 広小路ガーデンアベニュー 4F です
  • Whisperでの文字起こし:愛知県名古屋市中村久明駅4-24-16 広小路ガーデンアベニュー4階です。
  • GPT-4 Turboでの抽出:愛知県名古屋市中村区久明町4-24-16 広小路ガーデンアベニュー4階

読み方は問題ないですが、中村区名駅中村久明駅と漢字が異なりますね。

中村久明駅で検索してみましたが、出てこなかったため存在しない駅名ですね。

住所に関しては、建物名や地名などの読みは合っていますが、漢字が適していないことがありました。固有名詞の漢字は難しいようです。

名前

名前は、こちらの「すごい名前生成器」でランダム生成されたものを利用します。

プロンプトは下記の通りです。カタカナに変換するように指示しています。漢字だと一致する可能性が低いためです。

    input_text = """
    ## 役割
    あなたはコールセンターの担当者です。ルールに遵守し、顧客の発話から人名のみを出力してください。
    ## ルール
     1. 顧客から人名を含めたお問い合わせが文字起こしされます。
     2. 推測される人名を部分のみを返してください
     3. 人名はカタカナに変換してください
    """
  • 発話内容:私の名前は、よしだまさとです。
  • Whisperでの文字起こし:私の名前は吉田雅人です。
  • GPT-4 Turboでの抽出:ヨシダマサト

Whisperでは、漢字で名前を文字起こししていますが、「まさと」は、「雅人」以外にも「真人」など多くあるため、GPT-4 Turboでカタカナに問題なく抽出できていますね。

他にも下記の人名でも正しく認識しました。

  • やまもと さとし
  • すずきあい
  • こかわここあ

ただし、「すごい名前生成器」の珍しい姓名で発話すると、誤認識する場合もありました。

  • 発話内容:つだか りあです。
  • Whisperでの文字起こし:つばかり屋です
  • GPT-4 Turboでの抽出:ツバカリ屋

英字

英字のA~Zを聞き取れるか確認します。

プロンプトは下記の通りです。

    input_text = """
    ## 役割
    あなたはコールセンターの担当者です。ルールに遵守し、顧客の発話から会員番号である英字のみを出力してください。
    ## ルール
     1. 会員番号は、英字で構成されています
     2. 顧客から会員番号(英字)を含めたお問い合わせが文字起こしされます。
     3. 会員番号(英字)部分のみを返してください
     2. 英字を1文字ずつカンマで区切ってください
    """
  • 発話内容:ABCDEFGHIJKLMNです
  • Whisperでの文字起こし:A B C D E F G H I J K L M N です
  • GPT-4 Turboでの抽出:A, B, C, D, E, F, G, H, I, J, K, L, M, N

ABCDEFGHIJKLMNで抽出すると、Connectの音声出力では1つの単語として出力されるため、1文字ずつ発話するようにカンマで区切っています。

正しく内容が認識されていますね。

後半の英字は下記の通りです。

  • 発話内容:OPQRSTUVWXYZです
  • Whisperでの文字起こし:OPQRSTUVWXYZです。
  • GPT-4 Turboでの抽出:O,P,Q,R,S,T,U,V,W,X,Y,Z

後半の英字も正しく認識されています。

Whisperでは、英字の文字起こしにスペースを入れたり入れなかったりしていますね。

カンマで区切った場合

英字の羅列をDBなどに書き込む場合、カンマを取り除く必要があります。

コードの最後のreturnを下記にするとよいでしょう。correct_valueがカンマがない値です。

    return {
        "transcript": utterance_extraction,
        "correct_value": utterance_extraction.replace(",", "")
    }

数字

続いては、数字の認識を確認します。

0~9までの数字を発話をして検証しました。

プロンプトは下記の通りです。

    input_text = """
    ## 役割
    あなたはコールセンターの担当者です。ルールに遵守し、顧客の発話から数字のみを出力してください。
    ## ルール
    1. 漢数字は、次のようにアラビア数字に変換してください
      - 〇: 0
      - 一: 1
      - 二: 2
      - 三: 3
      - 四: 4
      - 五: 5
      - 六: 6
      - 七: 7
      - 八: 8
      - 九: 9
    2. アラビア数字を1桁ずつカンマで区切ってください
      - 例:〇,〇,〇,〇,〇,〇
    3. 「以下のように数字を変換しました」などの文言は不要です。
    """

プロンプトでは、1桁ずつカンマをつけるように指示しています。英字と同様に、Connectで「6023548791」を音声出力する場合、「六十億二千三百五十四万八千七百九十一」と音声が流れるためです。

電話番号では、一文字ずつ伝える方が分かりやすいので、カンマを入れています。

  • 発話内容:1023456789です。
  • Whisperでの文字起こし:1、0、2、3、4、5、6、7、8、9です。
  • GPT-4 Turboでの抽出:1,0,2,3,4,5,6,7,8,9

問題なく正しく認識してくれていますね。

生年月日

最後に、西暦の年月日を確認します。

プロンプトは下記の通りです

    input_text = """
    ## 役割
    あなたはコールセンターの担当者です。ルールに遵守し、顧客の発話から誕生日のみを出力してください。
    ## ルール
    1. 漢数字は、次のようにアラビア数字に変換してください
      - 〇: 0
      - 一: 1
      - 二: 2
      - 三: 3
      - 四: 4
      - 五: 5
      - 六: 6
      - 七: 7
      - 八: 8
      - 九: 9
    2. 「以下のように変換しました」などの文言は不要です。
    3. 誕生日の日付を以下のフォーマットで答えてください。
     - 例:〇〇〇〇年〇〇月〇〇日
    """
  • 発話内容:誕生日は、2003年12月20日です
  • Whisperでの文字起こし:誕生日は2003年12月20日です。
  • GPT-4 Turboでの抽出:2003年12月20日

20日を「はつか」と発話しましたが、正しく認識してくれています。

結果

今回の検証での認識とその評価は、以下の結果となりました。

項目 認識 備考
住所 建物名や地名(固有名詞)が難しい
名前 一部誤認識あり
英字 問題なく認識した
数字 問題なく認識した
生年月日 問題なく認識した

英字、数字、生年月日に関しては、問題なく認識しました。

住所に関しては、読み方は問題ないが漢字が間違えていたり、建物名が誤認識することがありました。

名前も一部で誤認識がみられました。

どなたかの参考になれば幸いです

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.